본문으로 건너뛰기

힙(Heap)

힙이란?

힙은 특별한 규칙이 있는 완전 이진 트리입니다.

  • 최대 힙: 부모가 항상 자식보다 크거나 같습니다
  • 최소 힙: 부모가 항상 자식보다 작거나 같습니다
최대 힙 예시:
9
/ \
7 8
/ \ / \
3 5 4 6

최소 힙 예시:
1
/ \
3 2
/ \ / \
7 5 6 4

왜 힙을 사용하나요?

가장 큰 값이나 가장 작은 값을 빠르게 찾고 싶을 때 사용합니다.

  • 최대값/최소값 찾기: O(1)
  • 삽입/삭제: O(log n)

힙 구현

배열로 구현하는 최소 힙

class MinHeap {
constructor() {
this.heap = [];
}

// 부모 인덱스 구하기
getParentIndex(index) {
return Math.floor((index - 1) / 2);
}

// 왼쪽 자식 인덱스
getLeftChildIndex(index) {
return 2 * index + 1;
}

// 오른쪽 자식 인덱스
getRightChildIndex(index) {
return 2 * index + 2;
}

// 두 값 바꾸기
swap(index1, index2) {
[this.heap[index1], this.heap[index2]] = [this.heap[index2], this.heap[index1]];
}

// 삽입
insert(value) {
this.heap.push(value);
this.heapifyUp();
}

// 위로 올리기 (삽입 후)
heapifyUp() {
let index = this.heap.length - 1;

while (index > 0) {
const parentIndex = this.getParentIndex(index);

if (this.heap[parentIndex] <= this.heap[index]) {
break;
}

this.swap(parentIndex, index);
index = parentIndex;
}
}

// 최소값 제거
extractMin() {
if (this.heap.length === 0) return null;
if (this.heap.length === 1) return this.heap.pop();

const min = this.heap[0];
this.heap[0] = this.heap.pop();
this.heapifyDown();

return min;
}

// 아래로 내리기 (제거 후)
heapifyDown() {
let index = 0;

while (this.getLeftChildIndex(index) < this.heap.length) {
let smallerChildIndex = this.getLeftChildIndex(index);
const rightChildIndex = this.getRightChildIndex(index);

if (rightChildIndex < this.heap.length &&
this.heap[rightChildIndex] < this.heap[smallerChildIndex]) {
smallerChildIndex = rightChildIndex;
}

if (this.heap[index] <= this.heap[smallerChildIndex]) {
break;
}

this.swap(index, smallerChildIndex);
index = smallerChildIndex;
}
}

// 최소값 확인 (제거하지 않음)
peek() {
return this.heap.length === 0 ? null : this.heap[0];
}

// 크기
size() {
return this.heap.length;
}

// 비어있는지 확인
isEmpty() {
return this.heap.length === 0;
}
}

최대 힙 구현

class MaxHeap {
constructor() {
this.heap = [];
}

getParentIndex(index) {
return Math.floor((index - 1) / 2);
}

getLeftChildIndex(index) {
return 2 * index + 1;
}

getRightChildIndex(index) {
return 2 * index + 2;
}

swap(index1, index2) {
[this.heap[index1], this.heap[index2]] = [this.heap[index2], this.heap[index1]];
}

insert(value) {
this.heap.push(value);
this.heapifyUp();
}

heapifyUp() {
let index = this.heap.length - 1;

while (index > 0) {
const parentIndex = this.getParentIndex(index);

if (this.heap[parentIndex] >= this.heap[index]) {
break;
}

this.swap(parentIndex, index);
index = parentIndex;
}
}

extractMax() {
if (this.heap.length === 0) return null;
if (this.heap.length === 1) return this.heap.pop();

const max = this.heap[0];
this.heap[0] = this.heap.pop();
this.heapifyDown();

return max;
}

heapifyDown() {
let index = 0;

while (this.getLeftChildIndex(index) < this.heap.length) {
let largerChildIndex = this.getLeftChildIndex(index);
const rightChildIndex = this.getRightChildIndex(index);

if (rightChildIndex < this.heap.length &&
this.heap[rightChildIndex] > this.heap[largerChildIndex]) {
largerChildIndex = rightChildIndex;
}

if (this.heap[index] >= this.heap[largerChildIndex]) {
break;
}

this.swap(index, largerChildIndex);
index = largerChildIndex;
}
}

peek() {
return this.heap.length === 0 ? null : this.heap[0];
}

size() {
return this.heap.length;
}

isEmpty() {
return this.heap.length === 0;
}
}

다른 방법들과 성능 비교

최대값 찾기 비교

function compareMaxFinding() {
const data = Array.from({length: 100000}, () => Math.floor(Math.random() * 1000000));

// 1. 배열에서 Math.max 사용
let start = performance.now();
for (let i = 0; i < 1000; i++) {
Math.max(...data);
}
let end = performance.now();
const mathMaxTime = end - start;

// 2. 배열 순회로 최대값 찾기
start = performance.now();
for (let i = 0; i < 1000; i++) {
let max = data[0];
for (let j = 1; j < data.length; j++) {
if (data[j] > max) max = data[j];
}
}
end = performance.now();
const loopTime = end - start;

// 3. 최대 힙 사용
const maxHeap = new MaxHeap();
data.forEach(value => maxHeap.insert(value));

start = performance.now();
for (let i = 0; i < 1000; i++) {
maxHeap.peek(); // 최대값 확인만
}
end = performance.now();
const heapTime = end - start;

console.log(`Math.max: ${mathMaxTime.toFixed(2)}ms`);
console.log(`반복문: ${loopTime.toFixed(2)}ms`);
console.log(`힙: ${heapTime.toFixed(2)}ms`);
}

힙은 최대값 찾기가 O(1)이므로 반복적으로 최대값을 찾을 때 가장 빠릅니다

정렬 비교 (힙 정렬 vs 다른 정렬들)

function compareSort() {
const size = 10000;

// 테스트 데이터 생성
function generateRandomArray() {
return Array.from({length: size}, () => Math.floor(Math.random() * size));
}

// 힙 정렬
function heapSort(arr) {
const heap = new MaxHeap();
arr.forEach(value => heap.insert(value));

const result = [];
while (!heap.isEmpty()) {
result.unshift(heap.extractMax());
}
return result;
}

// 성능 테스트
let data = generateRandomArray();
let start = performance.now();
heapSort([...data]);
let end = performance.now();
const heapSortTime = end - start;

data = generateRandomArray();
start = performance.now();
[...data].sort((a, b) => a - b);
end = performance.now();
const quickSortTime = end - start;

data = generateRandomArray();
start = performance.now();
bubbleSort([...data]);
end = performance.now();
const bubbleSortTime = end - start;

console.log(`힙 정렬: ${heapSortTime.toFixed(2)}ms`);
console.log(`자바스크립트 내장 정렬: ${quickSortTime.toFixed(2)}ms`);
console.log(`버블 정렬: ${bubbleSortTime.toFixed(2)}ms`);
}

function bubbleSort(arr) {
for (let i = 0; i < arr.length; i++) {
for (let j = 0; j < arr.length - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
}
}
}
return arr;
}

JavaScript 내장 정렬이 가장 빠르고, 힙 정렬은 중간, 버블 정렬이 가장 느립니다

우선순위 큐로 활용

힙은 우선순위 큐를 만들 때 가장 많이 사용됩니다.

class PriorityQueue {
constructor(isMaxPriority = false) {
this.heap = isMaxPriority ? new MaxHeap() : new MinHeap();
}

enqueue(value, priority) {
this.heap.insert({value, priority});
}

dequeue() {
const item = this.heap.extractMin() || this.heap.extractMax();
return item ? item.value : null;
}

peek() {
const item = this.heap.peek();
return item ? item.value : null;
}

isEmpty() {
return this.heap.isEmpty();
}

size() {
return this.heap.size();
}
}

// 우선순위가 낮은 숫자일수록 먼저 처리되는 큐
class MinPriorityQueue extends MinHeap {
insert(value, priority) {
super.insert({value, priority});
}

heapifyUp() {
let index = this.heap.length - 1;

while (index > 0) {
const parentIndex = this.getParentIndex(index);

if (this.heap[parentIndex].priority <= this.heap[index].priority) {
break;
}

this.swap(parentIndex, index);
index = parentIndex;
}
}

heapifyDown() {
let index = 0;

while (this.getLeftChildIndex(index) < this.heap.length) {
let smallerChildIndex = this.getLeftChildIndex(index);
const rightChildIndex = this.getRightChildIndex(index);

if (rightChildIndex < this.heap.length &&
this.heap[rightChildIndex].priority < this.heap[smallerChildIndex].priority) {
smallerChildIndex = rightChildIndex;
}

if (this.heap[index].priority <= this.heap[smallerChildIndex].priority) {
break;
}

this.swap(index, smallerChildIndex);
index = smallerChildIndex;
}
}

extractMin() {
const item = super.extractMin();
return item ? item.value : null;
}

peek() {
const item = super.peek();
return item ? item.value : null;
}
}

실제 문제 예시

K번째로 큰 수 찾기

function findKthLargest(nums, k) {
const minHeap = new MinHeap();

// k개만 유지하는 최소 힙
for (let num of nums) {
minHeap.insert(num);
if (minHeap.size() > k) {
minHeap.extractMin();
}
}

return minHeap.peek();
}

console.log(findKthLargest([3,2,1,5,6,4], 2)); // 5 (2번째로 큰 수)
console.log(findKthLargest([3,2,3,1,2,4,5,5,6], 4)); // 4 (4번째로 큰 수)

작업 스케줄링

function scheduleJobs(jobs) {
// 우선순위가 높은 작업부터 처리
const priorityQueue = new MinPriorityQueue();

jobs.forEach(job => {
priorityQueue.insert(job.name, job.priority);
});

const schedule = [];
while (!priorityQueue.isEmpty()) {
schedule.push(priorityQueue.extractMin());
}

return schedule;
}

const jobs = [
{name: "이메일 확인", priority: 3},
{name: "긴급 회의", priority: 1},
{name: "점심 식사", priority: 5},
{name: "보고서 작성", priority: 2},
{name: "코드 리뷰", priority: 2}
];

console.log(scheduleJobs(jobs));
// ["긴급 회의", "보고서 작성", "코드 리뷰", "이메일 확인", "점심 식사"]

언제 사용하나요?

  • 가장 큰/작은 값을 자주 찾아야 할 때
  • 우선순위 큐가 필요할 때
  • 데이터를 부분적으로 정렬하고 싶을 때
  • 메모리를 효율적으로 사용하면서 빠른 접근이 필요할 때

힙은 완전 이진 트리 구조를 배열로 구현하므로 메모리를 효율적으로 사용하면서도 빠른 성능을 제공합니다.